fix(banner): make hidden content inert and fix live region semantics - #5056
fix(banner): make hidden content inert and fix live region semantics#5056lukemorawski wants to merge 8 commits into
Conversation
MikitasK
left a comment
There was a problem hiding this comment.
looks pretty solid 👍
just a few points to address before merge:
cc2bc30 to
5181930
Compare
| <View | ||
| testID={`${testID ?? 'banner'}-icon`} | ||
| style={styles.icon} | ||
| aria-hidden={!iconAccessibilityLabel} | ||
| accessible={iconAccessibilityLabel ? true : undefined} | ||
| aria-label={iconAccessibilityLabel} | ||
| > |
There was a problem hiding this comment.
could we add role="img" when iconAccessibilityLabel is provided?
this View renders as a generic <div> on web
react native web doesn't implement accessible prop (source) and a generic element cannot have an accessible name. that's why wrapper’s aria-label isn't guaranteed to label nested image ((source)
| <View | |
| testID={`${testID ?? 'banner'}-icon`} | |
| style={styles.icon} | |
| aria-hidden={!iconAccessibilityLabel} | |
| accessible={iconAccessibilityLabel ? true : undefined} | |
| aria-label={iconAccessibilityLabel} | |
| > | |
| <View | |
| testID={`${testID ?? 'banner'}-icon`} | |
| style={styles.icon} | |
| aria-hidden={!iconAccessibilityLabel} | |
| accessible={iconAccessibilityLabel ? true : undefined} | |
| aria-label={iconAccessibilityLabel} | |
| role={iconAccessibilityLabel ? 'img' : undefined} | |
| > |
| maxFontSizeMultiplier={maxFontSizeMultiplier} | ||
| {exited ? null : ( | ||
| <Animated.View | ||
| testID={`${testID ?? 'banner'}-content`} |
There was a problem hiding this comment.
Only derive these when testID is set - the snapshots show banner-content, banner-row and banner-message on banners that were given no testID. Default test ids conflict with the user's own (#4998).
| const hasLiveRegion = () => Platform.OS === 'android' || Platform.OS === 'web'; | ||
|
|
||
| // a nested <Text> would otherwise be dropped from the announcement | ||
| const extractText = (node: React.ReactNode): string => |
There was a problem hiding this comment.
child.props.children breaks on any child that doesn't render its own children, so <Banner><Message /></Banner> announces nothing on iOS. React.Children is the composition problem v6 is meant to remove (#4954) - what's the alternative here?
There was a problem hiding this comment.
yep, and it looks like it's not just iOS. Both the android and web live regions will go empty too. Checked it: zero announceForAccessibility calls on iOS, empty announcer on Android, visible text renders fine. dead silent on every platform. Thanks for bringing that up!
i see it liek that:
- add
messageAccessibilityLabel, matchingiconAccessibilityLabelin this PR - keep extraction as the default so plain text keeps working
- warn in dev when extraction is empty but children are not
React.Children stays as best effort with an opt out, not as main mechanism. but i'm happy to drop extraction entirely or park this for v6 instead. any preference?
| React.useEffect(() => { | ||
| const focused = focusedAction.current; | ||
|
|
||
| if (focused === null || focused < actionCount) { |
There was a problem hiding this comment.
focusedAction.current isn't cleared on hide - focused < actionCount returns first. Hide, then show with actions={[]}, and focus jumps to the message. The test that covers this keeps one action, so it short-circuits too.
| textColor={colors.primary} | ||
| theme={theme} | ||
| {...others} | ||
| touchableRef={mergeRefs( |
There was a problem hiding this comment.
mergeRefs returns a new callback every render, so React detaches and reattaches each action ref on every commit — a consumer's touchableRef callback gets null then the node each time. Maybe try to memoize it.
Motivation
Bannerwas only visually hidden but content stayed mounted, screen readers could still reach it, and action buttons remained tabbable. The live region also had conflicting semantics and simply didn't work on Android.aria-hidden,pointerEventsguard orinert. Withvisible={false}, both actions still hadtabIndex: 0and accepted focus in the browser a11y tree.role="alert"+aria-live="polite"didn't agree.alertimplies assertive + atomic, while Chrome ended up withalert atomic live="polite".aria-live→accessibilityLiveRegiononView, notText, so the prop was ignored by the native text node. In practice it only worked on web.actionswas unbounded, and animation callbacks fired on mount and ontheme.animation.scalechanges even when visibility didn't change. Two existing tests already called this out as probably a bug.Related issue
Fixes #5055
Part of #4990
Changes
Inertness. Content becomes
aria-hidden+pointerEvents="none"+inerton web as soon as hiding starts, then unmounts after the exit animation - basically the same lifecycleSnackbaralready uses. There's still one inert measuring pass when mounting hidden so the spacer gets the right height. Layout stays the same.Live region. Moved from
Textto aView, which makes it actually work on Android. It's also scoped to the message only, so action labels don't re-announce the whole banner.roleandaria-livenow match.New
urgentprop.falseby default:role="status"+aria-live="polite"true:role="alert"+aria-live="assertive"On iOS the message is announced explicitly with
announceForAccessibilityWithOptions({ queue: !urgent }): queued for normal banners, interrupting for urgent ones. This is iOS-only to avoid double announcements elsewhere. Interpolated children like<Banner>Hello {name}</Banner>are flattened before announcing.Actions. Limited to 2, with a development warning for extras. They can now sit inline with the message when there's enough room instead of always dropping below it.
Focus. If a focused action disappears, focus moves to the nearest remaining action, or the message region if there are none. During hiding the content is inert, so focus is simply released. Restoring it to whatever opened the banner needs consumer-owned API/state and is out of scope here.
Callbacks.
onShowAnimationFinished/onHideAnimationFinishednow only run after actualvisibletransitions - not on mount, animation-scale changes, or interrupted animations.Breaking changes
Test plan
yarn typecheckyarn lintyarn test- 55 suites, 760 tests. Banner coverage went from 13 to 41 tests; the two "probably a bug" tests were inverted rather than removed.expo start --web)Hidden state, browser a11y tree. Before, both actions were still focusable:
Afterwards the message and actions disappear from the tree completely. The page tab count drops by exactly those two buttons (from 31 to 29), then returns when the banner is shown again.
Visible state.
alert atomic live="polite"becomesstatus atomic live="polite". The live region contains only the message; buttons are siblings.Android / TalkBack. The native a11y tree contains
banner-content, message and actions while visible, and none of them while hidden.iOS / VoiceOver. Full show > hide > show cycle gives message + actions >
[]> message + actions again. Theurgentannouncement was also checked.